Are your polymorphic relationships correctly enforced?

My client project uses a polymorphic relationship between several models in an effort to create a flexible system of associations.

However, I realized that this system was too flexible because it did not enforce the relationships as expected.

Our base

Here’s the domain we’ll be working with in this tutorial. The important thing to note is that a product has_many :pictures and an employee has_one :picture.

class Employee < ApplicationRecord
  has_one :picture, as: :imageable
end

class Product < ApplicationRecord
  has_many :pictures, as: :imageable
end

class Picture < ApplicationRecord
  belongs_to :imageable, polymorphic: true
end

The problem

I’ve previously written about the limitations of a has_one relationship, and this is no different. As you can see, it’s still possible to associate more than one picture with an employee.

employee = Employee.last
employee.create_picture
Picture.create(imagable: employee)

Picture.where(imagable: employee).count
# => 2

A naïve solution

In the previous article we solved this by creating a unique index. Since we’re working with a polymorphic relationship, we’ll need to make this index on the imageable columns.

class AddContstraintToPictures < ActiveRecord::Migration[7.1]
  def up
    add_index :pictures, [:imageable_type, :imageable_id],
      unique: true,
      name: "by_employee"
  end

  def down
    remove_index :pictures, name: "by_employee"
  end
end

Then, we can compliment the unique index by adding a validates_uniqueness_of validation.

--- a/app/models/picture.rb
+++ b/app/models/picture.rb
@@ -1,3 +1,5 @@
 class Picture < ApplicationRecord
   belongs_to :imageable, polymorphic: true
+
+  validates_uniqueness_of :imageable_type, scope: :imageable_id
 end

However, this approach is too heavy-handed. Although it prevents an employee from having more than one picture, it also prevents a product from having more than one picture.

product = Product.last
product.pictures.create!

picture = product.pictures.build
picture.valid?
=> false

picture.errors.messages
=> {:imageable_type=>["has already been taken"]}

An improved solution

What we need is a partial index. This allows us to conditionally enforce the uniqueness constraint. In this case, we want to do this when the imageable_type = "Employee".

--- a/db/migrate/20231123105601_add_contstraint_to_pictures.rb
+++ b/db/migrate/20231123105601_add_contstraint_to_pictures.rb
@@ -2,7 +2,8 @@ class AddContstraintToPictures < ActiveRecord::Migration[7.1]
   def up
     add_index :pictures, [:imageable_type, :imageable_id],
       unique: true,
-      name: "by_employee"
+      name: "by_employee",
+      where: "imageable_type = 'Employee'"
   end

We can also add this conditional to the uniqueness validation by using the conditional option.

--- a/app/models/picture.rb
+++ b/app/models/picture.rb
@@ -1,5 +1,6 @@
 class Picture < ApplicationRecord
   belongs_to :imageable, polymorphic: true

-  validates_uniqueness_of :imageable_type, scope: :imageable_id
+  validates_uniqueness_of :imageable_type, scope: :imageable_id,
+    conditions: -> { where(imageable_type: "Employee") }
 end

Wrapping up

Although this solution enforces our conditional uniqueness constraint in both the database and application, it’s not necessarily the most flexible solution. If you introduce a new model with has_one :picture, as: :imageable, you’ll need to modify the database index.

Instead, you might want to consider just leveraging the validation at the application level, knowing that it’s possible duplicate records could still be added.